iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
Build on Google AI

零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統系列 第 10 篇

【Day 10】極端狀況防禦策略:運用 LockService 與衝突檢查打造「零重複預約」系統

  • 分享至 

  • xImage
  •  

之前,我們順利實現了一鍵將預約資訊直連寫入 Google Calendar 的功能。然而在實際營運中,最讓系統管理者頭痛的極端狀況莫過於「搶場地/搶資源」——當兩位職工在同一秒鐘按下「送出預約」,或是兩人在不知道對方正在填表的情況下選擇了相同的場地與時段,系統該如何應對?

今天我們要來處理這個關鍵的競態條件(Race Condition)!透過在 Google Apps Script (GAS) 後端寫入防呆檢查與 LockService(互斥鎖),確保兩人同時提交同一個房間時,系統能準確攔截衝突時段,達到 100% 零重複預約!

一、 為什麼光靠前端檢查還不夠?(Race Condition 競態條件)

許多初學者會在前端(React)載入資料時檢查該時段是否被預約,如果空閒才允許點擊。但這種做法存在嚴重的安全盲區:

⚠️ 常見漏洞情境:

  1. 時間差(Time-to-Check to Time-to-Use): 使用者 A 在 10:00 開啟表單(顯示有空檔),填寫表單花費了 2 分鐘。

  2. 並發請求(Concurrency): 使用者 B 在 10:01 同樣開啟表單並於 10:02:00 按下送出;使用者 A 也在 10:02:01 按下送出。

  3. 結果: 兩筆請求幾乎同時抵達後端,若後端沒有控制機制,兩筆資料都會成功寫入 Google Calendar,造成 Overbooking (重複預約)

[使用者 A] ──(10:02:00 送出請求) ──┐
                                 ├──► [GAS 後端無鎖處理] ──► 建立事件 A ─┐
[使用者 B] ──(10:02:01 送出請求) ──┘                    └──► 建立事件 B ─┴─► 💥 雙方成功,重複預約!

二、 解法核心:LockService + Calendar 重疊檢查

為了達成嚴密的防護,後端處理邏輯必須採用「先鎖定、再檢查、後寫入」的三部曲策略:

               ┌─────────────────────────────────────────┐
               │ 1. 請求進入,嘗試取得 LockService 互斥鎖    │
               └────────────────────┬────────────────────┘
                                    │
                                    ▼
               ┌───────────────────────────────────────────┐
               │ 2. 檢查指定場地在該時段是否已有事件 (Conflict) │
               └──────────┬────────────────────┬───────────┘
                          │                    │
                   [有重疊 / 衝突]            [無衝突]
                          │                    │
                          ▼                    ▼
               ┌────────────────────┐ ┌────────────────────┐
               │ 回傳 CONFLICT 錯誤  │ │ 3. 寫入 Calendar    │
               └──────────┬─────────┘ └────────┬───────────┘
                          │                    │
                          └──────────┬─────────┘
                                     │
                                     ▼
               ┌─────────────────────────────────────────┐
               │ 4. 關鍵步驟:於 finally 區塊強制釋放鎖      │
               └─────────────────────────────────────────┘
  1. LockService(互斥鎖): 強制讓後端程式排隊。當請求 A 進入時會鎖定執行區塊,請求 B 必須等待請求 A 處理完成後才能進入。

  2. 時間重疊檢查 (Overlap Check): 在鎖定保護下,查詢 Google Calendar 指定場地在該時段是否已存在其他活動,若有重疊則直接攔截並退回。

三、 GAS 後端完整實作程式碼 (Code.gs)

請將以下程式碼更新至你的 Google Apps Script 專案中:

JavaScript

// 設定共享的 Google Calendar ID (請替換為你的日曆 ID)
const GOOGLE_CALENDAR_ID = 'your_organization_calendar_id@group.calendar.google.com';

/**
 * 處理 POST 請求 (API 入口)
 */
function doPost(e) {
  // 1. 取得指令碼鎖 (Script Lock)
  const lock = LockService.getScriptLock();
  
  // 嘗試獲取鎖,最多等待 10 秒 (10000 毫秒)
  const success = lock.tryLock(10000);
  
  if (!success) {
    return createJsonResponse({
      status: 'ERROR',
      message: '系統繁忙中(其他人正在同時預約),請稍後再試一次!'
    });
  }

  try {
    const data = JSON.parse(e.postData.contents);

    if (data.action === 'createBooking') {
      return handleCreateBookingWithLock(data);
    }

    return createJsonResponse({ status: 'ERROR', message: '無效的 Action' });

  } catch (error) {
    return createJsonResponse({ status: 'ERROR', message: error.toString() });
  } finally {
    // 💡 關鍵步驟:無論成功或失敗,最後務必釋放鎖,避免造成死鎖 (Deadlock)
    lock.releaseLock();
  }
}

/**
 * 核心預約建立邏輯 (含衝突檢查)
 */
function handleCreateBookingWithLock(data) {
  const calendar = CalendarApp.getCalendarById(GOOGLE_CALENDAR_ID);
  
  if (!calendar) {
    throw new Error('找不到指定的 Google Calendar,請檢查 Calendar ID 設定。');
  }

  const start = new Date(data.startTime);
  const end = new Date(data.endTime);

  // 基礎時間合理性檢查
  if (start >= end) {
    return createJsonResponse({ status: 'ERROR', message: '結束時間必須晚於開始時間!' });
  }

  // 2. 進行時間重疊檢查 (Conflict Check)
  // 取得該時間區間內的所有日曆事件
  const existingEvents = calendar.getEvents(start, end);
  
  // 篩選出屬於「同一個場地/資源」的衝突事件
  const hasConflict = existingEvents.some(event => {
    const title = event.getTitle();
    const location = event.getLocation();
    
    // 透過標題格式 [場地名稱] 或 Location 欄位進行比對
    return title.includes(`[${data.roomName}]`) || location === data.roomName;
  });

  if (hasConflict) {
    return createJsonResponse({
      status: 'CONFLICT',
      message: `預約失敗!【${data.roomName}】在您選擇的時段已有其他人預約,請重新選擇時段。`
    });
  }

  // 3. 通過檢查,正式建立 Google 日曆事件
  const eventTitle = `[${data.roomName}] ${data.purpose} (${data.applicantName})`;
  const eventDescription = `申請部門/姓名:${data.applicantName}\n預約場地:${data.roomName}\n活動內容:${data.purpose}\n由預約系統自動建立。`;

  const event = calendar.createEvent(eventTitle, start, end, {
    description: eventDescription,
    location: data.roomName,
  });

  // 設定事件視覺顏色 (可依據需求調整)
  if (data.roomId === 'RM-A1') {
    event.setColor(CalendarApp.EventColor.PALE_GREEN);
  } else if (data.roomId === 'RM-B1') {
    event.setColor(CalendarApp.EventColor.PALE_BLUE);
  }

  // 4. (可選) 寫入 Google Sheets 作為 Audit Log 備份
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Bookings');
  if (sheet) {
    sheet.appendRow([
      event.getId(),
      data.applicantName,
      data.roomId,
      data.startTime,
      data.endTime,
      data.purpose,
      new Date(),
    ]);
  }

  return createJsonResponse({
    status: 'SUCCESS',
    eventId: event.getId(),
    message: '成功建立 Google 日曆事件!',
  });
}

/**
 * 輔助函式:統一包裝 JSON 回傳格式
 */
function createJsonResponse(payload) {
  return ContentService.createTextOutput(JSON.stringify(payload))
    .setMimeType(ContentService.MimeType.JSON);
}

四、 核心技術深度解析

  1. tryLock(timeoutInMilliseconds) 的超時機制
JavaScript

const success = lock.tryLock(10000);

tryLock(10000) 表示當有其他請求正在執行時,當前請求最多排隊等待 10 秒。

  • 10 秒內: 前一個請求處理完成並解鎖,當前請求立即接手執行。

  • 超過 10 秒: 若系統極度繁忙仍未拿到鎖,tryLock 會回傳 false。此時程式會優雅地回傳提示訊息「系統繁忙,請稍後再試」,避免請求無休止地 hang 住。

  1. 為什麼一定要使用 try...finally 區塊釋放鎖?
JavaScript

try {
  // 預約處理...
} finally {
  lock.releaseLock(); // 必不可少!
}

💡 極度重要原則:
無論預約成功、時間重複、或是程式中間遭遇 JSON 解析錯誤與 Calendar API 異常,都必須在 finally 區塊中執行 lock.releaseLock()!如果忘記釋放鎖,後續所有預約請求都將被擋在門外直到逾時。

  1. 精準的時間重疊判斷 (Overlap Logic)
    calendar.getEvents(start, end) 會找出所有在查詢區間內交集的事件。我們透過 JS 的 .some() 搭配字串比對:
JavaScript

title.includes(`[${data.roomName}]`) || location === data.roomName

這樣能確保:

  • 預約 活動室 A1 時,只會檢查 活動室 A1 的衝突。

  • 若其他人預約的是 活動室 B1,則完全不受影響,兩者可平行順暢提交!

五、 前端 UX 配合與異常處理

當 GAS 後端回傳 status === 'CONFLICT' 時,React 前端可以進行相應的體驗優化:

TypeScript

if (result.status === 'CONFLICT') {
  // 1. 跳出警告提示
  alert(result.message);
  
  // 2. 觸發背景重新整理 (Re-fetch),更新 Day 8 的 FullCalendar 看板
  refetchCalendarEvents();
}

這樣一來,使用者不僅知道自己為什麼沒有預約成功,畫面上的日曆看板也會自動刷新,秀出剛剛被其他人搶先一步預約走的時段,大幅降低使用者的迷惘感。

六、 結語

今天我們透過 LockService 互斥鎖 與 Calendar 時間重疊檢查,為這套零成本場地預約系統補上了最強大的後端防衛機制,徹底杜絕了 Overbooking 的可能!


上一篇
【Day 9】預約輸入畫面與 Google Calendar 直連整合:一鍵建立全員共享日曆事件
下一篇
【Day 11】即時通知整合:利用 Telegram Bot 與 Email 實現預約雙軌自動化推播
系列文
零預算 NGO 數位轉型挑戰:30 天打造智慧訂房系統 共 11 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言